
// import * as d3 from "../lib/d3.js";
import * as d3 from "d3";
import {distance} from "../util/math.js";
import {$} from "../util/seq.js";
import {isNonKioskIFrameContext} from "../util/context.js";

const MIN_MOVE = 6;         // slack before a drag operation beings (pixels)
const MOVE_END_WAIT = 750;  // time to wait for a move operation to be considered done (millis)

/**
 * The input controller is an object that translates move operations (drag and/or zoom) into mutations of the
 * current globe's projection, and emits events so other page components can react to these move operations.
 *
 * D3's built-in Zoom behavior is used to bind to the document's drag/zoom events, and the input controller
 * interprets D3's events as move operations on the globe. This method is complicated due to the complex
 * event behavior that occurs during drag and zoom.
 *
 * D3 move operations usually occur as "start" -> ("zoom")* -> "end" event chain. During "zoom" events
 * the scale and mouse may change, implying a zoom or drag operation accordingly. These operations are quite
 * noisy. What should otherwise be one smooth continuous zoom is usually comprised of several "start" ->
 * "zoom" -> "end" event chains. A debouncer is used to eliminate the noise by waiting a short period of
 * time to ensure the user has finished the move operation.
 *
 * The "zoom" events may not occur; a simple click operation occurs as either "start" -> "end" or "start" ->
 * "zoom" -> "end". There is additional logic for other corner cases, such as spurious drags which move the globe
 * just a few pixels (most likely unintentional), and the tendency for some touch devices to issue events out of
 * order: "zoom" -> "start" -> "end" (though this may no longer occur with D3 v4).
 *
 * This object emits clean "moveStart" -> ("move")* -> "moveEnd" events for move/zoom operations, and "click"
 * events for everything else.
 *
 * @param {Handle} orientation
 * @param viewboxAgent
 * @returns {Object}
 */
export function buildInputController(orientation, viewboxAgent) {

    const dispatch = d3.dispatch("moveStart", "move", "moveEnd", "click");
    let globe = undefined;
    let op = null
    let prevClick = {time: 0, mouse: [0, 0]};
    let reorientInProgress = false;

    /**
     * @returns {Object} an object to represent the state for one move operation.
     */
    function newOp(startMouse, startScale) {
        return {
            type: "click",  // initially assumed to be a click operation
            startMouse: startMouse,
            startScale: startScale,
            manipulator: globe.manipulator(startMouse, startScale)
        };
    }

    function start(event) {
        op = op ?? newOp(d3.pointers(event)[0] ?? [NaN, NaN], globe.projection.scale());  // a new operation begins
    }

    function step(event) {
        const transform = event.transform ?? {};
        const currentMouse = d3.pointers(event)[0] ?? [NaN, NaN];
        const currentScale = transform.k ?? globe.projection.scale();
        op = op ?? newOp(currentMouse, 1);  // Fix bug on some browsers where zoomstart fires out of order.
        if (op.type === "click") {
            const distanceMoved = distance(currentMouse, op.startMouse);
            if (currentScale === op.startScale && (distanceMoved < MIN_MOVE || isNaN(distanceMoved))) {
                // to reduce annoyance, ignore op if mouse has barely moved and no zoom is occurring
                return;
            }
            dispatch.call("moveStart");
            op.type = "drag";
        }
        if (currentScale !== op.startScale || isNaN(currentMouse[0])) {
            // Whenever a scale change is detected, or mouse is undefined (such as during double-click),
            // stickily switch to a zoom operation.
            op.type = "zoom";
        }

        // when zooming, ignore whatever the mouse is doing--really cleans up behavior on touch devices
        op.manipulator.move(op.type === "zoom" ? null : currentMouse, currentScale);
        dispatch.call("move");
    }

    function shouldSuppressClickEvents() {
        // Ignore clicks that occur soon after the previous click and in the same location. Reduces noise
        // on touch devices where taps trigger both touchstart and mousedown events (i.e., two clicks).
        // CONSIDER: is this still necessary after d3 v4 upgrade?
        // Also, reorientation can emit zoom start + end. Make sure not to interpret that as a click.
        return (
            (Date.now() - prevClick.time < 500) && distance(prevClick.mouse, op.startMouse) < MIN_MOVE ||
            reorientInProgress
        );
    }

    function end() {
        if (op === null) return;
        op.manipulator.end();
        if (op.type === "click") {
            if (!shouldSuppressClickEvents()) {
                dispatch.call("click", null, op.startMouse, globe.projection.invert(op.startMouse) || []);
                prevClick = {time: Date.now(), mouse: op.startMouse};
            }
        } else {
            scheduleMoveEnd();
        }
        op = null;  // the drag/zoom/click operation is over
    }

    let moveEnding = null;
    function scheduleMoveEnd() {
        if (moveEnding) {
            clearTimeout(moveEnding);
        }
        moveEnding = setTimeout(() => {
            moveEnding = null;
            if (!op || op.type !== "drag" && op.type !== "zoom") {
                orientation.set(globe.getOrientation(), {source: "moveEnd"});
                dispatch.call("moveEnd");
            }
        }, MOVE_END_WAIT);  // wait for a bit to decide if user has stopped moving the globe
    }

    const zoom = d3.zoom()
        .on("start", start)
        .on("zoom", step)
        .on("end", end);

    const drag = d3.drag()
        .on("start", start)
        .on("drag", step)
        .on("end", end);

    // desktop: scroll -> zoom,   drag -> drag
    // mobile:   pinch -> zoom, scroll -> drag
    const display = d3.select("#display");
    if (isNonKioskIFrameContext()) {
        display.call(drag);
    } else {
        display.call(zoom)
            .on($`wheel`, event => event.preventDefault());  // prevent scrolling even when at scale extent
    }

    function reorient(meta) {
        if (!globe || meta?.source === "moveEnd") {
            // reorientation occurred because the user just finished a move operation, so globe is already
            // oriented correctly.
            return;
        }
        const originalValue = reorientInProgress;
        reorientInProgress = true;
        try {
            dispatch.call("moveStart");
            globe.setOrientation(orientation.get(), viewboxAgent.value());
            zoom.transform(display, d3.zoomIdentity.scale(globe.projection.scale()));
            dispatch.call("moveEnd");
        } finally {
            reorientInProgress = originalValue;
        }
    }

    function hotkey(operation, value) {
        if (operation === "zoom") {
            zoom.scaleBy(display, value);
            return true;
        }

        if (!moveEnding) {
            dispatch.call("moveStart");
        }

        const proj = globe.projection, rotate = proj.rotate(), delta = value / 8;
        switch (operation) {
            case "left":  proj.rotate([rotate[0] + delta, rotate[1]]); break;
            case "right": proj.rotate([rotate[0] - delta, rotate[1]]); break;
            case "up":    proj.rotate([rotate[0], rotate[1] - delta]); break;
            case "down":  proj.rotate([rotate[0], rotate[1] + delta]); break;
        }

        dispatch.call("move");
        scheduleMoveEnd();
        return true;
    }

    orientation.on($`change`, (delta, old, meta) => reorient(meta));

    return Object.assign(dispatch, {
        globe(x) {
            if (x) {
                globe = x;
                zoom.scaleExtent(globe.scaleExtent());
                reorient();
            }
            return x ? this : globe;
        },
        cancelMove() {
            // Forcefully end the current move operation, if any.
            end();
        },
        hotkey,
    });
}
